什么是原型?

原型是顺应人类自然思维的产物。中文中有个成语叫做“照猫画虎”,这里的猫看起来就是虎的原型,所以,由此我们可以看出,用原型来描述对象的方法可以说是古已有之。

在JS的面对对象文章里面写过,在不同的编程语言中,设计者也利用各种不同的语言特性来抽象描述对象。最为成功的是使用“类”的方式来描述对象,这诞生了诸如 C++、Java等流行的编程语言。这个流派叫做基于类的编程语言。还有一种就是基于原型的编程语言,它们利用原型来描述对象。我们的JavaScript就是其中代表。

“基于类”的编程提倡使用一个关注分类和类之间关系开发模型。在这类语言中,总是先有类,再从类去实例化一个对象。类与类之间又可能会形成继承、组合等关系。类又往往与语言的类型系统整合,形成一定编译时的能力。与此相对,“基于原型”的编程看起来更为提倡程序员去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将它们分成类。

基于原型的面向对象系统通过“复制”的方式来创建新对象。基于原型和基于类都能够满足基本的复用和抽象需求,但是适用的场景不太相同。

原型系统的“复制操作”有两种实现思路:

  • 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
  • 另一个是切实地复制对象,从此两个对象再无关联。

而JavaScript选择的是第一种:并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;

JavaScript的原型

如果抛开JavaScript用于模拟Java类的复杂语法设施(如new、Function Object、函数的prototype属性等),原型系统可以说相当简单,可以用两条概括:

  • 如果所有对象都有私有字段[[prototype]],就是对象的原型;
  • 读一个属性,如果对象本身没有,则会继续访问对象的原型,直到原型为空或者找到为止。

这个模型在ES的各个历史版本中并没有很大改变,但从 ES6 以来,JavaScript提供了一系列内置函数,以便更为直接地访问操纵原型。三个方法分别为:

  • Object.create 根据指定的原型创建新对象,原型可以是null;
  • Object.getPrototypeOf 获得一个对象的原型;
  • Object.setPrototypeOf 设置一个对象的原型。

利用这三个方法,可以完全抛开类的思维,利用原型来实现抽象和复用。如下面的代码展示了用原型来抽象猫和虎的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var cat = {
say(){
console.log("meow~");
},
jump(){
console.log("jump");
}
}

var tiger = Object.create(cat, {
say:{
writable:true,
configurable:true,
enumerable:true,
value:function(){
console.log("roar!");
}
}
})

var anotherCat = Object.create(cat);

anotherCat.say();

var anotherTiger = Object.create(tiger);

anotherTiger.say();

这段代码创建了一个“猫”对象,又根据猫做了一些修改创建了虎,之后我们完全可以用Object.create来创建另外的猫和虎对象,我们可以通过“原始猫对象”和“原始虎对象”来控制所有猫和虎的行为。

原型链

关于JS的原型链,因为比较复杂加上JS语言的设计缺陷,就不详细描述了,具体可以看下面这张图,Person是自定义的对象,如果图看着不是太明白的话,可以写代码自定义个类,分别在浏览器中打印出原型、原型指向,再来看这个图。

早期版本中的类与原型

在早期版本的JavaScript中,“类”的定义是一个私有属性 [[class]],语言标准为内置类型诸如Number、String、Date等指定了[[class]]属性,以表示它们的类。语言使用者唯一可以访问[[class]]属性的方式是Object.prototype.toString。因此,在ES3和之前的版本,JS中类的概念是相当弱的,它仅仅是运行时的一个字符串属性。

考虑到JavaScript语法中跟Java相似的部分,我们对类的讨论不能用“new运算是针对构造器对象,而不是类”来试图回避。所以,我们仍然要把new理解成JavaScript面向对象的一部分,那么new操作具体做了哪些事情?

new 运算接受一个构造器和一组调用参数,实际上做了几件事:

  • 以构造器的 prototype 属性(注意与私有字段[[prototype]]的区分)为原型,创建新对象;
  • 将 this 和调用参数传给构造器,执行;
  • 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。

new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性。

如下面代码,就展示了用构造器模拟类的两种方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function c1(){
this.p1 = 1;
this.p2 = function(){
console.log(this.p1);
}
}
var o1 = new c1;
o1.p2();
//--------------------------
function c2(){
}
c2.prototype.p1 = 1;
c2.prototype.p2 = function(){
console.log(this.p1);
}

var o2 = new c2;
o2.p2();

第一种方法是直接在构造器中修改this,给this添加属性。

第二种方法是修改构造器的prototype属性指向的对象,它是从这个构造器构造出来的所有对象的原型。

ES6 中的类

好在ES6中加入了新特性class,new跟function搭配的怪异行为终于可以退休了(虽然运行时没有改变),因此推荐使用ES6的语法来定义类,而令function回归原本的函数语义。

ES6中引入了class关键字,并且在标准中删除了所有[[class]]相关的私有属性描述,类的概念正式从属性升级成语言的基础设施,从此,基于类的编程方式成为了JavaScript的官方编程范式。

类的基本写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Rectangle {
constructor(height, width) {
this.height = height;
this.width = width;
}
// Getter
get area() {
return this.calcArea();
}
// Method
calcArea() {
return this.height * this.width;
}
}

在现有的类语法中,getter/setter和method是兼容性最好的。

我们通过get/set关键字来创建getter,通过括号和大括号来创建方法,数据型成员最好写在构造器里面。

类的写法实际上也是由原型运行时来承载的,逻辑上JavaScript认为每个类是有共同原型的一组对象,类中定义的方法和属性则会被写在原型对象之上。

此外,最重要的是,类提供了继承能力。我们来看一下下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Animal { 
constructor(name) {
this.name = name;
}

speak() {
console.log(this.name + ' makes a noise.');
}
}

class Dog extends Animal {
constructor(name) {
super(name); // call the super class constructor and pass in the name parameter
}

speak() {
console.log(this.name + ' barks.');
}
}

let d = new Dog('Mitzie');
d.speak(); // Mitzie barks.

以上代码创造了Animal类,并且通过extends关键字让Dog继承了它,展示了最终调用子类的speak方法获取了父类的name。

比起早期的原型模拟方式,使用extends关键字自动设置了constructor,并且会自动调用父类的构造函数,这是一种更少坑的设计。

所以当我们使用类的思想来设计代码时,应该尽量使用class来声明类,而不是用旧语法,拿函数来模拟对象。一些激进的观点认为,class关键字和箭头运算符可以完全替代旧的function关键字,它更明确地区分了定义函数和定义类两种意图。